| 1 | "use client"; |
| 2 | import { useEffect, useState } from "react"; |
| 3 | import { useParams } from "next/navigation"; |
| 4 | import Link from "next/link"; |
| 5 | import { api, CoTravel, MovementSummary } from "@/lib/api"; |
| 6 | |
| 7 | export default function VehicleProfile() { |
| 8 | const { id } = useParams<{ id: string }>(); |
| 9 | const plate = decodeURIComponent(id).toUpperCase(); |
| 10 | const [m, setM] = useState<MovementSummary | null>(null); |
| 11 | const [cotravel, setCotravel] = useState<CoTravel[]>([]); |
| 12 | const [err, setErr] = useState<string | null>(null); |
| 13 | |
| 14 | useEffect(() => { |
| 15 | api.movement(plate) |
| 16 | .then(setM) |
| 17 | .catch((e) => setErr(e.status === 401 ? "Sign in to view this vehicle." : "No reads for this plate.")); |
| 18 | api.cotravel(plate).then((r) => setCotravel(r.candidates)).catch(() => {}); |
| 19 | }, [plate]); |
| 20 | |
| 21 | if (err) return <div className="badge danger">{err}</div>; |
| 22 | if (!m) return <div className="muted">Loading…</div>; |
| 23 | |
| 24 | const maxHour = Math.max(1, ...Object.values(m.by_hour)); |
| 25 | |
| 26 | return ( |
| 27 | <div> |
| 28 | <h1>Vehicle {m.plate_norm}</h1> |
| 29 | <div className="grid"> |
| 30 | <div className="card"><div className="muted">Total reads</div><div className="kpi">{m.total_reads}</div></div> |
| 31 | <div className="card"><div className="muted">Distinct cameras</div><div className="kpi">{m.distinct_cameras}</div></div> |
| 32 | <div className="card"><div className="muted">First seen</div><div>{fmt(m.first_seen)}</div></div> |
| 33 | <div className="card"><div className="muted">Last seen</div><div>{fmt(m.last_seen)}</div></div> |
| 34 | </div> |
| 35 | |
| 36 | {m.anomalies.length > 0 && ( |
| 37 | <div className="card" style={{ borderColor: "var(--danger)", marginTop: 14 }}> |
| 38 | <strong className="badge danger">⚠ {m.anomalies.length} movement anomaly</strong> |
| 39 | {m.anomalies.map((a, i) => ( |
| 40 | <div key={i} className="row" style={{ justifyContent: "space-between", marginTop: 6 }}> |
| 41 | <span className="muted">{a.reason.replace(/_/g, " ")}</span> |
| 42 | <span className="badge danger">{Math.round(a.speed_kmh)} km/h</span> |
| 43 | </div> |
| 44 | ))} |
| 45 | </div> |
| 46 | )} |
| 47 | |
| 48 | <h2>Activity by hour of day</h2> |
| 49 | <div className="card"> |
| 50 | <div style={{ display: "flex", alignItems: "flex-end", gap: 3, height: 120 }}> |
| 51 | {Array.from({ length: 24 }, (_, h) => { |
| 52 | const v = m.by_hour[String(h)] || 0; |
| 53 | return ( |
| 54 | <div key={h} style={{ flex: 1, textAlign: "center" }}> |
| 55 | <div title={`${h}:00 - ${v} reads`} style={{ |
| 56 | height: `${(v / maxHour) * 100}px`, background: v ? "var(--accent)" : "var(--border)", |
| 57 | borderRadius: 3, minHeight: 2 }} /> |
| 58 | <div className="muted" style={{ fontSize: 9 }}>{h}</div> |
| 59 | </div> |
| 60 | ); |
| 61 | })} |
| 62 | </div> |
| 63 | </div> |
| 64 | |
| 65 | <h2>Route legs</h2> |
| 66 | <div className="card"> |
| 67 | <table> |
| 68 | <thead><tr><th>From</th><th>To</th><th>Elapsed</th><th>Distance</th><th>Speed</th></tr></thead> |
| 69 | <tbody> |
| 70 | {m.route.map((l, i) => ( |
| 71 | <tr key={i}> |
| 72 | <td className="muted">{l.from?.slice(0, 8)}</td> |
| 73 | <td className="muted">{l.to?.slice(0, 8)}</td> |
| 74 | <td>{Math.round(l.elapsed_s)}s</td> |
| 75 | <td>{l.distance_km ? l.distance_km.toFixed(1) + " km" : "-"}</td> |
| 76 | <td>{l.speed_kmh ? <span className={"badge " + (l.implausible ? "danger" : "ok")}>{Math.round(l.speed_kmh)}</span> : "-"}</td> |
| 77 | </tr> |
| 78 | ))} |
| 79 | {!m.route.length && <tr><td colSpan={5} className="muted">Single sighting - no route.</td></tr>} |
| 80 | </tbody> |
| 81 | </table> |
| 82 | </div> |
| 83 | |
| 84 | <h2>Possible co-travelers</h2> |
| 85 | <p className="muted">Vehicles repeatedly seen alongside {m.plate_norm} within the co-travel window - candidate associates.</p> |
| 86 | <div className="card"> |
| 87 | <table> |
| 88 | <thead><tr><th>Plate</th><th>Shared sightings</th></tr></thead> |
| 89 | <tbody> |
| 90 | {cotravel.map((c) => ( |
| 91 | <tr key={c.plate_norm}> |
| 92 | <td><Link href={`/vehicles/${c.plate_norm}`}>{c.plate_norm}</Link></td> |
| 93 | <td><span className="badge">{c.shared_sightings}</span></td> |
| 94 | </tr> |
| 95 | ))} |
| 96 | {!cotravel.length && <tr><td colSpan={2} className="muted">No co-travel candidates.</td></tr>} |
| 97 | </tbody> |
| 98 | </table> |
| 99 | </div> |
| 100 | </div> |
| 101 | ); |
| 102 | } |
| 103 | |
| 104 | function fmt(s: string | null) { return s ? new Date(s).toLocaleString() : "-"; } |